Use Google Calendar API calendar invites / Implement Push Notifications#1529
Use Google Calendar API calendar invites / Implement Push Notifications#1529davinotdavid wants to merge 48 commits intomainfrom
Conversation
5cee063 to
0c9b274
Compare
0c9b274 to
e92d7e8
Compare
b58077b to
522e171
Compare
… changes on defaults
…k when receiving webhook calls
|
@MelissaAutumn ah I've also created #1607 to track the perf boost idea you've mentioned now that we would have webhooks setup (Also thank you the all the through reviews, really appreciate you 🙏) |
MelissaAutumn
left a comment
There was a problem hiding this comment.
Looking better! Some more nits here, I need a chance next week to test this out. 😄
| 'type': 'web_hook', | ||
| 'address': webhook_url, | ||
| }, | ||
| ).execute() |
There was a problem hiding this comment.
Perfect, it'll be good to have state so folks who knew a channel id can't just send random events to our webhooks.
|
|
||
| def delete_event(self, uid: str): | ||
| def confirm_event( | ||
| self, event_id: str, event: schemas.Event = None, organizer_language: str = None, |
There was a problem hiding this comment.
I think Noneable types need to either be wrapped in Optional[<type>] or <type>|None.
| ) | ||
| self.bust_cached_events() | ||
|
|
||
| def delete_event(self, uid: str, send_updates: str = 'none'): |
There was a problem hiding this comment.
we might want to enum that send_updates. The google library we pull in might already have it done for us.
| token = get_google_token(google_client, calendar.external_connection) | ||
| google_client.stop_channel(channel.channel_id, channel.resource_id, token) | ||
| except Exception as e: | ||
| logging.warning(f'[google_watch] Failed to stop channel {channel.channel_id}: {e}') |
There was a problem hiding this comment.
In the future this should be turned into a celery task, and this exception might be a good play to run a retry_task call.
| @@ -1 +1,13 @@ | |||
| from . import appointment, attendee, availability, calendar, external_connection, schedule, slot, subscriber # noqa: F401 | |||
| # ruff: noqa | |||
There was a problem hiding this comment.
I think you can remove this.
| try: | ||
| remote_event = google_client.get_event(remote_calendar_id, appointment.external_id, google_token) | ||
| if remote_event and remote_event.get('attendees'): | ||
| for att in remote_event['attendees']: |
There was a problem hiding this comment.
| for att in remote_event['attendees']: | |
| for att in remote_event.get('attendees', []): |
Eh just to be safe.
| f'via Google Calendar, slot {slot.id} booked' | ||
| ) | ||
|
|
||
| elif response_status == 'declined': |
There was a problem hiding this comment.
We should enum these (google's library might already have 'em.)
| from appointment.commands.renew_google_channels import run | ||
|
|
||
| log.info('Starting Google Calendar channel renewal') | ||
| run() |
There was a problem hiding this comment.
We might want to wrap this in an exception catch, for anything else we're not catching.
| 'renew-google-channels': { | ||
| 'task': 'appointment.tasks.google.renew_google_channels', | ||
| 'schedule': google_channel_renew_interval, | ||
| }, |
There was a problem hiding this comment.
We should probably store this outside of this object, but we can do that later.
| SENTRY_DSN= | ||
| # Possible values: prod, dev, test | ||
| APP_ENV=test | ||
| GOOGLE_INVITE_ENABLED=True |
There was a problem hiding this comment.
Do we have the ability to handle watch channels locally? If not then we should default this to false.
We might want to split the variables for google invites vs handling watch channels though.
Description of the Change
TL;DR: If the default calendar (the one selected in the Availability) is a calendar attached to a Google Account, we are now using Google Calendar's email flow instead of our own. We retrofit the event decisions into Appointment through Push Notifications into a webhook.
Shouldn't be super dangerous as there's an env var (
GOOGLE_INVITE_ENABLED) that functions as a feature flag that isFalsein prod andTruein non-prod environments.PS: Sorry for the super long PR, lots of cases to cover
--
Whenever a Subscriber has a google account / google calendar as their default calendar, we are passing the control of the invitation emails to Google through their Events API. This is done through using
insertinstead ofimportwhile adding the event to the remote calendar.Not having our own invitation emails means that we lose the visibility on the Confirm / Deny / Cancel options so in order to solve for it, this PR implements their Push Notifications workflow that work as follows:
sequenceDiagram participant Bookee participant App participant GoogleCal as Google Calendar participant Subscriber Bookee->>App: Request time slot App->>GoogleCal: "insert() with bookee as attendee, status=tentative" GoogleCal-->>Bookee: Google Calendar invite (tentative) App-->>Subscriber: Branded confirmation email alt Subscriber confirms (email/UI) Subscriber->>App: Accept App->>GoogleCal: "patch() status=confirmed, sendUpdates=all" GoogleCal-->>Bookee: Update notification (confirmed) App->>App: Book slot, update DB status else Subscriber denies (email/UI) Subscriber->>App: Deny App->>GoogleCal: "delete() sendUpdates=all" GoogleCal-->>Bookee: Cancellation email App->>App: Mark slot declined else Bookee declines (Google Calendar) Bookee->>GoogleCal: Decline invite GoogleCal-->>Subscriber: Decline notification email GoogleCal->>App: Watch webhook App->>App: Mark slot declined, delete event else Bookee accepts (Google Calendar) Bookee->>GoogleCal: Accept invite GoogleCal->>App: Watch webhook Note over App: No action - subscriber must still confirm endThe above is a partial graph but consider that the Subscriber's actions through the Google Calendar Event would also backtrack to our application! Also, there's no HOLD event for this flow anymore.
Here's some specific use-cases to consider:
For users that opt to connect a google account / google calendar we now create an entry in the
google_calendar_channelstable so that we have a sync token to compare with when the webhook is triggered with updates.Added a one-off command that can be ran to back-fill the watch channels required for the push notifications to work if the user has the default calendar as a Google Calendar and it is currently connected.
When a calendar is connected through the Settings, if the external connection is a google connection, we are still not creating the watch channel. We only do so if the calendar is set as default. Likewise, if the calendar is not the default for a schedule anymore, we teardown the watch channel.
There is a command to renew the sync tokens for the channels that are in the
google_calendar_channelstable. However, we don't have a task queue setup yet for this so this has to be ran manually for now.How to test
Note
Unfortunately, the Push Notifications API / Google Webhook require a publicly available URL with HTTPS. The most seamlessly way that I could find was to use Cloudflare Tunnels pointing to a personal domain that I already own and routing that to localhost:5000.
docker compose down -v && docker compose up -d --buildand before you do anything else, update theget_webhook_url's backend_url to be the publicly exposed HTTPS URL (backend/src/appointment/controller/google_watch.py).Some other cases to test:
Benefits
importmethod (which doesn't send Google emails) instead ofinsert(which sends Google emails) and using our own emails for notification, unless the recipient had already interacted with no-reply@appointment.tb.pro or manually clicked in the "Add to calendar" button in their email, they wouldn't have the event added to their calendar automatically.Known issues / Things to improve
We need to renew the watch channels periodically but we don't have yet a task queue setup. We had a brief discussion on using Celery but instead of having a new infrastructure for it inside Appointment, we can explore re-using the existing on in Accounts (TBD though). To mitigate this I can manually add to my calendar to run the commands :/Now we have Celery! 🎉Applicable Issues
Fixes #1393